Skip to content

fix(rybbit): queue custom events before script loads#585

Merged
harlan-zw merged 5 commits intomainfrom
fix/461-rybbit-refresh-events
Jan 20, 2026
Merged

fix(rybbit): queue custom events before script loads#585
harlan-zw merged 5 commits intomainfrom
fix/461-rybbit-refresh-events

Conversation

@harlan-zw
Copy link
Collaborator

🔗 Linked issue

Resolves #461

❓ Type of change

  • 📖 Documentation (updates to the documentation or readme)
  • 🐞 Bug fix (a non-breaking change that fixes an issue)
  • 👌 Enhancement (improving an existing functionality)
  • ✨ New feature (a non-breaking change that adds functionality)
  • 🧹 Chore (updates to the build process or auxiliary tools and libraries)
  • ⚠️ Breaking change (fix or feature that would cause existing functionality to change)

📚 Description

Custom events (like proxy.event()) failed silently after page refresh but worked after SPA navigation. The root cause was that use() returned null when window.rybbit was undefined, breaking the proxy's ability to queue calls before the script loaded.

Added clientInit to create a stub that queues calls, and flushQueue() that replays them once the real script loads. This follows the same pattern used by Google Analytics and Plausible.

// Events now work immediately, even before script loads
const { proxy } = useScriptRybbitAnalytics({ siteId: '874' })
proxy.event('my_event', { data: 'value' }) // queued if script not ready, sent when loaded

harlan-zw and others added 4 commits January 16, 2026 01:58
Combined commits:
- fix(plausible): use consistent window reference in clientInit stub
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@vercel
Copy link
Contributor

vercel bot commented Jan 18, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Review Updated (UTC)
scripts-docs Ready Ready Preview, Comment Jan 20, 2026 1:05am
scripts-playground Ready Ready Preview, Comment Jan 20, 2026 1:05am

@pkg-pr-new
Copy link

pkg-pr-new bot commented Jan 18, 2026

Open in StackBlitz

npm i https://pkg.pr.new/nuxt/scripts/@nuxt/scripts@585

commit: aa50bd0

Comment on lines +90 to +101
const flushQueue = () => {
const state = getRybbitState()
if (!state || state.flushed || !isRybbitReady()) return
state.flushed = true
while (state.queue.length > 0) {
const [method, ...args] = state.queue.shift()!
const fn = (window.rybbit as any)[method]
if (typeof fn === 'function') {
fn.apply(window.rybbit, args)
}
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Queued calls will never be flushed because flushQueue() is only called in use(), which is executed once during initialization before the script loads. When the script later becomes ready, there's no mechanism to flush the queued calls.

View Details
📝 Patch Details
diff --git a/src/runtime/registry/rybbit-analytics.ts b/src/runtime/registry/rybbit-analytics.ts
index fcf02c1..1c7d634 100644
--- a/src/runtime/registry/rybbit-analytics.ts
+++ b/src/runtime/registry/rybbit-analytics.ts
@@ -80,16 +80,17 @@ function getRybbitState(): RybbitQueueState | undefined {
 }
 
 export function useScriptRybbitAnalytics<T extends RybbitAnalyticsApi>(_options?: RybbitAnalyticsInput) {
-  // Check if real Rybbit is loaded
-  const isRybbitReady = () => import.meta.client
+  // Check if real Rybbit is loaded (not our stub)
+  const isRealRybbit = () => import.meta.client
     && typeof window !== 'undefined'
     && window.rybbit
     && typeof window.rybbit.event === 'function'
+    && !('_isStub' in window.rybbit)
 
   // Flush queued calls to real implementation
   const flushQueue = () => {
     const state = getRybbitState()
-    if (!state || state.flushed || !isRybbitReady()) return
+    if (!state || state.flushed || !isRealRybbit()) return
     state.flushed = true
     while (state.queue.length > 0) {
       const [method, ...args] = state.queue.shift()!
@@ -102,7 +103,7 @@ export function useScriptRybbitAnalytics<T extends RybbitAnalyticsApi>(_options?
 
   // Wrapper that queues or calls directly
   const callOrQueue = (method: string, ...args: any[]) => {
-    if (isRybbitReady()) {
+    if (isRealRybbit()) {
       const fn = (window.rybbit as any)[method]
       if (typeof fn === 'function') {
         fn.apply(window.rybbit, args)
@@ -133,7 +134,7 @@ export function useScriptRybbitAnalytics<T extends RybbitAnalyticsApi>(_options?
       schema: import.meta.dev ? RybbitAnalyticsOptions : undefined,
       scriptOptions: {
         use() {
-          // Flush queue when use() is called (happens on status changes)
+          // Flush queue when use() is called (when script becomes ready)
           flushQueue()
           // Return wrappers that queue if not ready
           return {
@@ -141,11 +142,30 @@ export function useScriptRybbitAnalytics<T extends RybbitAnalyticsApi>(_options?
             event: (name: string, properties?: Record<string, any>) => callOrQueue('event', name, properties),
             identify: (userId: string) => callOrQueue('identify', userId),
             clearUserId: () => callOrQueue('clearUserId'),
-            getUserId: () => window.rybbit?.getUserId?.() ?? null,
+            getUserId: () => (window.rybbit as any)?._isStub ? null : window.rybbit?.getUserId?.() ?? null,
             rybbit: window.rybbit,
           } as RybbitAnalyticsApi
         },
       },
+      // Create a stub that queues calls until the real script loads
+      clientInit: import.meta.server
+        ? undefined
+        : () => {
+            const w = window as any
+            if (!w.rybbit) {
+              const state = getRybbitState()
+              if (state) {
+                w.rybbit = {
+                  _isStub: true,
+                  pageview: function () { state.queue.push(['pageview', ...arguments]) },
+                  event: function () { state.queue.push(['event', ...arguments]) },
+                  identify: function () { state.queue.push(['identify', ...arguments]) },
+                  clearUserId: function () { state.queue.push(['clearUserId', ...arguments]) },
+                  getUserId: () => null,
+                }
+              }
+            }
+          },
     }
   }, _options)
 }

Analysis

Queued Rybbit analytics calls never flushed before script loads

What fails: Events tracked immediately after page load (before the Rybbit script loads) are silently lost. Calls to proxy.event(), proxy.identify(), and proxy.pageview() made before the external script initializes are queued but never processed.

How to reproduce:

  1. Refresh page on the Rybbit Analytics test page: playground/pages/third-parties/rybbit-analytics.vue
  2. Before the status changes to "loaded", click "Track Immediate Event" button
  3. Check browser console or network tab - the event is never sent to Rybbit's servers
  4. Navigate away and back (SPA navigation) - subsequent events work correctly

Result: Queued events remain in globalThis[Symbol.for('nuxt-scripts.rybbit-queue')] forever and are never executed. The queue state persists but has no mechanism to trigger flushing after the real script loads.

Expected: Events queued before the script loads should be automatically flushed and sent to Rybbit when the script becomes ready.

Root cause: The previous fix (commit 6720a6a) created a stub with clientInit hook that initialized the queue, but the recent change (commit aa50bd0) removed clientInit and replaced the stub detection with globalThis-based queueing. However, without clientInit, there's no mechanism to create the stub before user code runs. The use() function is only called once during initialization (before the script loads), and flushQueue() returns early because isRealRybbit() checks fail. When the real script loads later, there's no mechanism to call flushQueue() again.

Solution: Restore the clientInit hook to create a stub with _isStub: true marker. The stub's methods queue calls, and isRealRybbit() now checks for the absence of _isStub to detect when the real script has loaded. When real script loads, it replaces the stub, and subsequent calls or the next use() invocation will trigger flushQueue() successfully.

@harlan-zw harlan-zw merged commit cdfb697 into main Jan 20, 2026
10 checks passed
@harlan-zw harlan-zw deleted the fix/461-rybbit-refresh-events branch January 20, 2026 01:36
@harlan-zw harlan-zw mentioned this pull request Jan 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Rybbit analytics custom events not working when refreshing page until you navigate to another page

1 participant